Coverage Report

Created: 2026-04-26 08:04

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
D:\a\csshw\csshw\xtask\src\inject_agent_token.rs
Line
Count
Source
1
//! Paseo agent GitHub auth injection.
2
//!
3
//! A paseo-spawned agent would otherwise inherit the user's full `gh`
4
//! login — including classic scopes like `repo` that allow deleting
5
//! repositories or force-pushing to `main`. This module is the
6
//! counterpart of that risk: on worktree creation, it writes a
7
//! per-worktree `.claude/settings.local.json` whose `env` map carries
8
//! a fine-grained PAT supplied by the contributor. Claude Code
9
//! injects that `env` into the agent process, and `gh` honors
10
//! `GH_TOKEN` over the keyring, so the agent ends up acting as the
11
//! scoped PAT while the contributor's own `gh` session outside paseo
12
//! is unaffected.
13
//!
14
//! The token source is `<source-checkout>/.paseo/gh-token` — a
15
//! gitignored file the contributor creates once per clone. The
16
//! source checkout path is taken from the `PASEO_SOURCE_CHECKOUT_PATH`
17
//! environment variable paseo sets when running setup steps; if that
18
//! variable is absent, the current directory is used instead, which
19
//! covers manual `cargo xtask inject-agent-token` invocations from
20
//! the repo root.
21
//!
22
//! If the token file is missing the subcommand is a silent no-op
23
//! (with an informational log line). If it contains anything other
24
//! than a fine-grained PAT — e.g. a classic `ghp_…` or OAuth `gho_…`
25
//! token — the subcommand aborts, since those token types grant far
26
//! more than the least-privilege goal allows.
27
28
use std::path::{Path, PathBuf};
29
30
use anyhow::{bail, Context, Result};
31
32
/// Expected prefix for a fine-grained personal access token. Classic
33
/// tokens (`ghp_`) and OAuth tokens (`gho_`) are rejected to preserve
34
/// the least-privilege property — classic tokens cannot be restricted
35
/// to specific repositories or to a subset of repository permissions.
36
const FINE_GRAINED_PREFIX: &str = "github_pat_";
37
38
/// Relative path inside the source checkout where the contributor
39
/// stores their fine-grained PAT.
40
const TOKEN_FILE_REL_PATH: &str = ".paseo/gh-token";
41
42
/// Relative path inside the worktree where Claude Code reads local,
43
/// uncommitted per-project settings.
44
const SETTINGS_FILE_REL_PATH: &str = ".claude/settings.local.json";
45
46
/// All side-effecting operations performed by this subcommand.
47
///
48
/// Implement with mocks in tests to achieve zero filesystem,
49
/// environment, or process side-effects.
50
pub trait InjectAgentTokenSystem {
51
    /// Look up an environment variable.
52
    ///
53
    /// # Arguments
54
    ///
55
    /// * `key` - Environment variable name.
56
    ///
57
    /// # Returns
58
    ///
59
    /// `Some(value)` when the variable is set and non-empty,
60
    /// `None` otherwise.
61
    fn env_var(&self, key: &str) -> Option<String>;
62
63
    /// Return the current working directory.
64
    ///
65
    /// # Errors
66
    ///
67
    /// Returns an error if the current directory cannot be
68
    /// determined.
69
    fn current_dir(&self) -> Result<PathBuf>;
70
71
    /// Read the token file at `path`.
72
    ///
73
    /// # Arguments
74
    ///
75
    /// * `path` - Absolute or worktree-relative path to the token
76
    ///   file.
77
    ///
78
    /// # Returns
79
    ///
80
    /// `Ok(Some(contents))` when the file exists and is readable,
81
    /// `Ok(None)` when it does not exist (the subcommand treats
82
    /// this as a no-op).
83
    ///
84
    /// # Errors
85
    ///
86
    /// Returns an error for filesystem failures other than
87
    /// "not found" (for example, permission denied).
88
    fn read_token_file(&self, path: &Path) -> Result<Option<String>>;
89
90
    /// Write `contents` to the settings file at `path`, creating
91
    /// any missing parent directories.
92
    ///
93
    /// # Arguments
94
    ///
95
    /// * `path` - Target path for the settings file.
96
    /// * `contents` - Full file contents to write.
97
    ///
98
    /// # Errors
99
    ///
100
    /// Returns an error if directory creation or the write fails.
101
    fn write_settings(&self, path: &Path, contents: &str) -> Result<()>;
102
103
    /// Emit an informational or warning message to the user.
104
    ///
105
    /// # Arguments
106
    ///
107
    /// * `msg` - Message to display.
108
    fn log(&self, msg: &str);
109
}
110
111
/// Production implementation of [`InjectAgentTokenSystem`].
112
pub struct RealSystem;
113
114
#[cfg_attr(coverage_nightly, coverage(off))]
115
impl InjectAgentTokenSystem for RealSystem {
116
    fn env_var(&self, key: &str) -> Option<String> {
117
        std::env::var(key).ok().filter(|v| !v.is_empty())
118
    }
119
120
    fn current_dir(&self) -> Result<PathBuf> {
121
        std::env::current_dir().context("failed to resolve current directory")
122
    }
123
124
    fn read_token_file(&self, path: &Path) -> Result<Option<String>> {
125
        match std::fs::read_to_string(path) {
126
            Ok(contents) => Ok(Some(contents)),
127
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
128
            Err(err) => Err(err).with_context(|| format!("failed to read {}", path.display())),
129
        }
130
    }
131
132
    fn write_settings(&self, path: &Path, contents: &str) -> Result<()> {
133
        if let Some(parent) = path.parent() {
134
            std::fs::create_dir_all(parent)
135
                .with_context(|| format!("failed to create {}", parent.display()))?;
136
        }
137
        std::fs::write(path, contents)
138
            .with_context(|| format!("failed to write {}", path.display()))?;
139
        Ok(())
140
    }
141
142
    fn log(&self, msg: &str) {
143
        println!("{msg}");
144
    }
145
}
146
147
/// Build the JSON body written to `.claude/settings.local.json`.
148
///
149
/// Caller-enforced invariant: `token` contains only bytes in
150
/// `[A-Za-z0-9_]`. That alphabet has no characters that require JSON
151
/// escaping, which is what lets this function skip a general-purpose
152
/// JSON encoder without risking injection. The invariant is enforced
153
/// by [`is_fine_grained_pat_alphabet`] inside [`inject_agent_token`].
154
///
155
/// # Arguments
156
///
157
/// * `token` - Fine-grained PAT, already validated and trimmed.
158
///
159
/// # Returns
160
///
161
/// A pretty-printed JSON document terminated with a newline.
162
2
fn build_settings_body(token: &str) -> String {
163
2
    format!(
164
        "{{\n  \"env\": {{\n    \"GH_TOKEN\": \"{token}\",\n    \"GH_HOST\": \"github.com\"\n  }}\n}}\n"
165
    )
166
2
}
167
168
/// Return `true` when every byte of `token` is in the fine-grained
169
/// PAT alphabet `[A-Za-z0-9_]`.
170
///
171
/// Enforcing this invariant is what lets [`build_settings_body`]
172
/// embed the token directly into a JSON template without escaping —
173
/// none of the characters in this alphabet need JSON escaping, so a
174
/// token that passes this check cannot break out of its string
175
/// literal nor inject additional keys.
176
///
177
/// # Arguments
178
///
179
/// * `token` - Trimmed token to validate.
180
///
181
/// # Returns
182
///
183
/// `true` when `token` is non-empty and contains only the allowed
184
/// characters; `false` otherwise.
185
3
fn is_fine_grained_pat_alphabet(token: &str) -> bool {
186
3
    !token.is_empty()
187
3
        && token
188
3
            .bytes()
189
55
            .
all3
(|b| b.is_ascii_alphanumeric() ||
b == b'_'7
)
190
3
}
191
192
/// Resolve the source checkout directory.
193
///
194
/// Paseo passes `PASEO_SOURCE_CHECKOUT_PATH` into `worktree.setup`
195
/// subprocesses. When the variable is missing — for example when the
196
/// subcommand is invoked manually — fall back to the current
197
/// directory so running it from the repo root behaves intuitively.
198
///
199
/// # Arguments
200
///
201
/// * `system` - Injected I/O provider.
202
///
203
/// # Returns
204
///
205
/// The source checkout path.
206
///
207
/// # Errors
208
///
209
/// Returns an error only when the fallback `current_dir` lookup
210
/// fails.
211
8
fn resolve_source_checkout<S: InjectAgentTokenSystem>(system: &S) -> Result<PathBuf> {
212
8
    if let Some(
path7
) = system.env_var("PASEO_SOURCE_CHECKOUT_PATH") {
213
7
        return Ok(PathBuf::from(path));
214
1
    }
215
1
    system.current_dir()
216
8
}
217
218
/// Inject the contributor's fine-grained GitHub PAT into the
219
/// current worktree's Claude Code settings.
220
///
221
/// The token is read from `<source-checkout>/.paseo/gh-token`. A
222
/// missing token file is treated as an opt-out: the function logs a
223
/// notice and returns `Ok(())` so worktree creation is not blocked
224
/// for contributors who have not set a token up yet.
225
///
226
/// # Arguments
227
///
228
/// * `system` - Injected I/O provider.
229
///
230
/// # Returns
231
///
232
/// `Ok(())` on success or when the token file is absent.
233
///
234
/// # Errors
235
///
236
/// Returns an error when a token file exists but does not start with
237
/// [`FINE_GRAINED_PREFIX`], when its trimmed contents fall outside
238
/// the fine-grained PAT alphabet (see [`is_fine_grained_pat_alphabet`]),
239
/// or when the settings file cannot be written.
240
8
pub fn inject_agent_token<S: InjectAgentTokenSystem>(system: &S) -> Result<()> {
241
8
    let source = resolve_source_checkout(system)
?0
;
242
8
    let token_file = source.join(TOKEN_FILE_REL_PATH);
243
244
8
    let Some(
raw6
) = system.read_token_file(&token_file)
?0
else {
245
2
        system.log(&format!(
246
2
            "INFO - paseo agent GitHub auth: no {} found; agents will use your existing gh login. See CONTRIBUTING.md.",
247
2
            token_file.display()
248
2
        ));
249
2
        return Ok(());
250
    };
251
252
6
    let token = raw.trim();
253
6
    if token.is_empty() {
254
1
        bail!(
255
            "{} is empty; expected a fine-grained PAT starting with `{}`. See CONTRIBUTING.md.",
256
1
            token_file.display(),
257
            FINE_GRAINED_PREFIX
258
        );
259
5
    }
260
5
    if !token.starts_with(FINE_GRAINED_PREFIX) {
261
2
        bail!(
262
            "{} must contain a fine-grained PAT (prefix `{}`); classic `ghp_…` and OAuth `gho_…` tokens are not accepted because they cannot be scoped tightly enough. See CONTRIBUTING.md.",
263
2
            token_file.display(),
264
            FINE_GRAINED_PREFIX
265
        );
266
3
    }
267
3
    if !is_fine_grained_pat_alphabet(token) {
268
1
        bail!(
269
            "{} contains characters outside the fine-grained PAT alphabet ([A-Za-z0-9_]); refusing to embed it in settings. See CONTRIBUTING.md.",
270
1
            token_file.display()
271
        );
272
2
    }
273
274
2
    let cwd = system.current_dir()
?0
;
275
2
    let settings_path = cwd.join(SETTINGS_FILE_REL_PATH);
276
2
    let body = build_settings_body(token);
277
2
    system.write_settings(&settings_path, &body)
?0
;
278
279
2
    system.log(&format!(
280
2
        "INFO - paseo agent GitHub auth: wrote {} from {} (scoped PAT)",
281
2
        settings_path.display(),
282
2
        token_file.display()
283
2
    ));
284
285
2
    Ok(())
286
8
}
287
288
#[cfg(test)]
289
#[path = "tests/test_inject_agent_token.rs"]
290
mod tests;